<template><h1 id="_15-消息队列和事件循环-页面是怎么-活-起来的" tabindex="-1"><a class="header-anchor" href="#_15-消息队列和事件循环-页面是怎么-活-起来的" aria-hidden="true">#</a> 15 | 消息队列和事件循环：页面是怎么“活”起来的？</h1>
<p>前面我们讲到了<strong>每个渲染进程都有一个主线程</strong>，并且主线程非常繁忙，既要处理 DOM，又要计算样式，还要处理布局，同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行，这就需要一个系统来统筹调度这些任务，这个统筹调度系统就是我们今天要讲的消息队列和事件循环系统。</p>
<p>在写这篇文章之前，我翻阅了大量的资料，却发现没有一篇文章能把消息循环系统给讲清楚的，所以我决定用一篇文章来专门介绍页面的事件循环系统。事件循环非常底层且非常重要，学会它能让你理解页面到底是如何运行的， 所以在本篇文章中，我们会将页面的事件循环给梳理清楚、讲透彻。</p>
<p>为了能让你更加深刻地理解事件循环机制，我们就从最简单的场景来分析，然后带你一步步了解浏览器页面主线程是如何运作的。</p>
<p>需要说明的是，文章中的代码我会采用 C++ 来示范。如果你不熟悉 C++，也没有关系，这里并没有涉及到任何复杂的知识点，只要你了解 JavaScript 或 Python，你就会看懂。</p>
<h2 id="使用单线程处理安排好的任务" tabindex="-1"><a class="header-anchor" href="#使用单线程处理安排好的任务" aria-hidden="true">#</a> 使用单线程处理安排好的任务</h2>
<p>我们先从最简单的场景讲起，比如有如下一系列的任务：</p>
<ul>
<li>
<p>任务 1：1+2</p>
</li>
<li>
<p>任务 2：20/5</p>
</li>
<li>
<p>任务 3：7*8</p>
</li>
<li>
<p>任务 4：打印出任务 1、任务 2、任务 3 的运算结果</p>
</li>
</ul>
<p>现在要在一个线程中去执行这些任务，通常我们会这样编写代码：</p>
<div class="language-c++ ext-c++ line-numbers-mode"><pre v-pre class="language-c++"><code>void MainThread(){
  int num1 = 1+2; //任务1
  int num2 = 20/5; //任务2
  int num3 = 7*8; //任务3
  print(&quot;最终计算的值为:%d,%d,%d&quot;,num1,num2,num3)； //任务4
}
</code></pre><div class="line-numbers" aria-hidden="true"><span class="line-number">1</span><br><span class="line-number">2</span><br><span class="line-number">3</span><br><span class="line-number">4</span><br><span class="line-number">5</span><br><span class="line-number">6</span><br></div></div><p>在上面的执行代码中，我们把所有任务代码按照顺序写进主线程里，等线程执行时，这些任务会按照顺序在线程中依次被执行；等所有任务执行完成之后，线程会自动退出。可以参考下图来直观地理解下其执行过程：</p>
<p><img src="https://static001.geekbang.org/resource/image/72/bc/72726678ac6604116c1d5dad160780bc.png" alt="第一版：线程的一次执行"></p>
<h2 id="在线程运行过程中处理新任务" tabindex="-1"><a class="header-anchor" href="#在线程运行过程中处理新任务" aria-hidden="true">#</a> 在线程运行过程中处理新任务</h2>
<p>但并不是所有的任务都是在执行之前统一安排好的，大部分情况下，新的任务是在线程运行过程中产生的。比如在线程执行过程中，又接收到了一个新的任务要求计算“10+2”，那上面那种方式就无法处理这种情况了。</p>
<p><strong>要想在线程运行过程中，能接收并执行新的任务，就需要采用事件循环机制</strong>。我们可以通过一个 for 循环语句来监听是否有新的任务，如下面的示例代码：</p>
<div class="language-c++ ext-c++ line-numbers-mode"><pre v-pre class="language-c++"><code>//GetInput
//等待用户从键盘输入一个数字，并返回该输入的数字
int GetInput(){
  int input_number = 0;
  cout&lt;&lt;&quot;请输入一个数:&quot;;
  cin&gt;&gt;input_number;
  return input_number;
}

//主线程(Main Thread)

void MainThread(){
  for(;;){
    int first_num = GetInput()；
    int second_num = GetInput()；
    result_num = first_num + second_num;
    print(&quot;最终计算的值为:%d&quot;,result_num)；
  }
}
</code></pre><div class="line-numbers" aria-hidden="true"><span class="line-number">1</span><br><span class="line-number">2</span><br><span class="line-number">3</span><br><span class="line-number">4</span><br><span class="line-number">5</span><br><span class="line-number">6</span><br><span class="line-number">7</span><br><span class="line-number">8</span><br><span class="line-number">9</span><br><span class="line-number">10</span><br><span class="line-number">11</span><br><span class="line-number">12</span><br><span class="line-number">13</span><br><span class="line-number">14</span><br><span class="line-number">15</span><br><span class="line-number">16</span><br><span class="line-number">17</span><br><span class="line-number">18</span><br><span class="line-number">19</span><br></div></div><p>相较于第一版的线程，这一版的线程做了两点改进。
**</p>
<ul>
<li>
<p>第一点引入了循环机制**，具体实现方式是在线程语句最后添加了一个 <strong>for 循环语句</strong>，线程会一直循环执行。</p>
</li>
<li>
<p><strong>第二点是引入了事件</strong>，可以在线程运行过程中，等待用户输入的数字，等待过程中线程处于暂停状态，一旦接收到用户输入的信息，那么线程会被激活，然后执行相加运算，最后输出结果。</p>
</li>
</ul>
<p>通过引入事件循环机制，就可以让该线程“活”起来了，我们每次输入两个数字，都会打印出两数字相加的结果，你可以结合下图来参考下这个改进版的线程：</p>
<p><img src="https://static001.geekbang.org/resource/image/9e/e3/9e0f595324fbd5b7cd1c1ae1140f7de3.png" alt="第二版：在线程中引入事件循环"></p>
<h2 id="处理其他线程发送过来的任务" tabindex="-1"><a class="header-anchor" href="#处理其他线程发送过来的任务" aria-hidden="true">#</a> 处理其他线程发送过来的任务</h2>
<p>上面我们改进了线程的执行方式，引入了事件循环机制，可以让其在执行过程中接受新的任务。不过在第二版的线程模型中，所有的任务都是来自于线程内部的，如果另外一个线程想让主线程执行一个任务，利用第二版的线程模型是无法做到的。</p>
<p>那下面我们就来看看其他线程是如何发送消息给渲染主线程的，具体形式你可以参考下图：</p>
<p><img src="https://static001.geekbang.org/resource/image/2e/05/2eb6a8ecb7cb528da4663573d74eb305.png" alt="渲染进程线程之间发送任务"></p>
<p>从上图可以看出，渲染主线程会频繁接收到来自于 IO 线程的一些任务，接收到这些任务之后，渲染进程就需要着手处理，比如接收到资源加载完成的消息后，渲染进程就要着手进行 DOM 解析了；接收到鼠标点击的消息后，渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。</p>
<p>那么如何设计好一个线程模型，能让其能够接收其他线程发送的消息呢？</p>
<p>一个通用模式是使用<strong>消息队列</strong>。在解释如何实现之前，我们先说说什么是消息队列，可以参考下图：</p>
<p><img src="https://static001.geekbang.org/resource/image/6d/5e/6d141ec0925590d83d97a37cce8e6f5e.png" alt="消息队列"></p>
<p>从图中可以看出，<strong>消息队列是一种数据结构，可以存放要执行的任务</strong>。它符合队列“<strong>先进先出</strong>”的特点，也就是说<strong>要添加任务的话，添加到队列的尾部；要取出任务的话，从队列头部去取。</strong></p>
<p>有了队列之后，我们就可以继续改造线程模型了，改造方案如下图所示：</p>
<p><img src="https://static001.geekbang.org/resource/image/2a/ab/2ac6bc0361cb4690c5cc83d8abad22ab.png" alt="第三版线程模型：队列 + 循环"></p>
<p>从上图可以看出，我们的改造可以分为下面三个步骤：</p>
<ol>
<li>
<p>添加一个消息队列；</p>
</li>
<li>
<p>IO 线程中产生的新任务添加进消息队列尾部；</p>
</li>
<li>
<p>渲染主线程会循环地从消息队列头部中读取任务，执行任务。</p>
</li>
</ol>
<p>有了这些步骤之后，那么接下来我们就可以<strong>按步骤使用代码来实现第三版的线程模型。</strong></p>
<p>首先，构造一个队列。当然，在本篇文章中我们不需要考虑队列实现的细节，只是构造队列的接口：</p>
<div class="language-c++ ext-c++ line-numbers-mode"><pre v-pre class="language-c++"><code>class TaskQueue{
  public:
  Task takeTask(); //取出队列头部的一个任务
  void pushTask(Task task); //添加一个任务到队列尾部
};
</code></pre><div class="line-numbers" aria-hidden="true"><span class="line-number">1</span><br><span class="line-number">2</span><br><span class="line-number">3</span><br><span class="line-number">4</span><br><span class="line-number">5</span><br></div></div><p>接下来，改造主线程，让主线程从队列中读取任务：</p>
<div class="language-c++ ext-c++ line-numbers-mode"><pre v-pre class="language-c++"><code>TaskQueue task_queue；
void ProcessTask();
void MainThread(){
  for(;;){
  Task task = task_queue.takeTask();
  ProcessTask(task);
  }
}
</code></pre><div class="line-numbers" aria-hidden="true"><span class="line-number">1</span><br><span class="line-number">2</span><br><span class="line-number">3</span><br><span class="line-number">4</span><br><span class="line-number">5</span><br><span class="line-number">6</span><br><span class="line-number">7</span><br><span class="line-number">8</span><br></div></div><p>在上面的代码中，我们添加了一个消息队列的对象，然后在主线程的 for 循环代码块中，从消息队列中读取一个任务，然后执行该任务，主线程就这样一直循环往下执行，因此只要消息队列中有任务，主线程就会去执行。</p>
<p>主线程的代码就这样改造完成了。这样改造后，主线程执行的任务都全部从消息队列中获取。所以如果有其他线程想要发送任务让主线程去执行，只需要将任务添加到该消息队列中就可以了，添加任务的代码如下：</p>
<div class="language-c++ ext-c++ line-numbers-mode"><pre v-pre class="language-c++"><code>Task clickTask;
task_queue.pushTask(clickTask)
</code></pre><div class="line-numbers" aria-hidden="true"><span class="line-number">1</span><br><span class="line-number">2</span><br></div></div><p>由于是多个线程操作同一个消息队列，所以在添加任务和取出任务时还会加上一个<strong>同步锁</strong>，这块内容你也要注意下。</p>
<h2 id="处理其他进程发送过来的任务" tabindex="-1"><a class="header-anchor" href="#处理其他进程发送过来的任务" aria-hidden="true">#</a> 处理其他进程发送过来的任务</h2>
<p>通过使用消息队列，我们实现了线程之间的消息通信。在 Chrome 中，跨进程之间的任务也是频繁发生的，那么如何处理其他进程发送过来的任务？你可以参考下图：</p>
<p><img src="https://static001.geekbang.org/resource/image/e2/c6/e2582e980632fd2df5043f81a11461c6.png" alt="跨进程发送消息"></p>
<p>从图中可以看出，<strong>渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息</strong>，接收到消息之后，会将这些消息组装成任务发送给渲染主线程，后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了，这里就不再重复了。</p>
<h2 id="消息队列中的任务类型" tabindex="-1"><a class="header-anchor" href="#消息队列中的任务类型" aria-hidden="true">#</a> 消息队列中的任务类型</h2>
<p>现在你知道页面主线程是如何接收外部任务的了，那接下来我们再来看看消息队列中的任务类型有哪些。你可以参考下<a href="https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/platform/task_type.h" target="_blank" rel="noopener noreferrer">Chromium 的官方源码<ExternalLinkIcon/></a>，这里面包含了很多内部消息类型，如输入事件（鼠标滚动、点击、移动）、微任务、文件读写、WebSocket、JavaScript 定时器等等。</p>
<p>除此之外，消息队列中还包含了很多与页面相关的事件，如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。</p>
<p>以上这些事件都是在主线程中执行的，所以在编写 Web 应用时，你还需要衡量这些事件所占用的时长，并想办法解决单个任务占用主线程过久的问题。</p>
<h2 id="如何安全退出" tabindex="-1"><a class="header-anchor" href="#如何安全退出" aria-hidden="true">#</a> 如何安全退出</h2>
<p>当页面主线程执行完成之后，又该如何保证页面主线程能够安全退出呢？Chrome 是这样解决的，确定要退出当前页面时，页面主线程会设置一个退出标志的变量，在每次执行完一个任务时，判断是否有设置退出标志。</p>
<p>如果设置了，那么就直接中断当前的所有任务，退出线程，你可以参考下面代码：</p>
<div class="language-c++ ext-c++ line-numbers-mode"><pre v-pre class="language-c++"><code>TaskQueue task_queue；
void ProcessTask();
bool keep_running = true;
void MainThread(){
  for(;;){
  Task task = task_queue.takeTask();
  ProcessTask(task);
  if(!keep_running) //如果设置了退出标志，那么直接退出线程循环
  break;
  }
}
</code></pre><div class="line-numbers" aria-hidden="true"><span class="line-number">1</span><br><span class="line-number">2</span><br><span class="line-number">3</span><br><span class="line-number">4</span><br><span class="line-number">5</span><br><span class="line-number">6</span><br><span class="line-number">7</span><br><span class="line-number">8</span><br><span class="line-number">9</span><br><span class="line-number">10</span><br><span class="line-number">11</span><br></div></div><h2 id="页面使用单线程的缺点" tabindex="-1"><a class="header-anchor" href="#页面使用单线程的缺点" aria-hidden="true">#</a> 页面使用单线程的缺点</h2>
<p>上面讲述的就是页面线程的循环系统是如何工作的，那接下来，我们继续探讨页面线程的一些特征。</p>
<p>通过上面的介绍，你应该清楚了，页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性，也就是说放入队列中的任务，需要等待前面的任务被执行完，才会被执行。鉴于这个属性，就有如下两个问题需要解决。</p>
<p><strong>第一个问题是如何处理高优先级的任务。</strong></p>
<p>比如一个典型的场景是监控 DOM 节点的变化情况（节点的插入、修改、删除等动态变化），然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是，利用 JavaScript 设计一套监听接口，当变化发生时，渲染引擎同步调用这些接口，这是一个典型的观察者模式。</p>
<p>不过这个模式有个问题，因为 DOM 变化非常频繁，如果每次发生变化的时候，都直接调用相应的 JavaScript 接口，那么这个当前的任务执行时间会被拉长，从而导致<strong>执行效率的下降</strong>。</p>
<p>如果将这些 DOM 变化做成异步的消息事件，添加到消息队列的尾部，那么又会影响到监控的实时性，因为在添加到消息队列的过程中，可能前面就有很多任务在排队了。</p>
<p>这也就是说，如果 DOM 发生变化，采用同步通知的方式，会影响当前任务的<strong>执行效率</strong>；如果采用异步方式，又会影响到<strong>监控的实时性</strong>。</p>
<p>那该如何权衡<strong>效率</strong>和<strong>实时性</strong>呢？</p>
<p>针对这种情况，微任务就应用而生了，下面我们来看看微任务是如何权衡效率和实时性的。</p>
<p>通常我们把消息队列中的任务称为<strong>宏任务</strong>，每个宏任务中都包含了一个<strong>微任务队列</strong>，在执行宏任务的过程中，如果 DOM 有变化，那么就会将该变化添加到微任务列表中，这样就不会影响到宏任务的继续执行，因此也就解决了执行效率的问题。</p>
<p>等宏任务中的主要功能都直接完成之后，这时候，渲染引擎并不着急去执行下一个宏任务，而是执行当前宏任务中的微任务，因为 DOM 变化的事件都保存在这些微任务队列中，这样也就解决了实时性问题。</p>
<p><strong>第二个是如何解决单个任务执行时长过久的问题。</strong></p>
<p>因为所有的任务都是在单线程中执行的，所以每次只能执行一个任务，而其他任务就都处于等待状态。如果其中一个任务执行时间过久，那么下一个任务就要等待很长时间。可以参考下图：</p>
<p><img src="https://static001.geekbang.org/resource/image/8d/cc/8de4b43fca99b180fdffe6a5af07b5cc.png" alt="单个任务执行时间过久"></p>
<p>从图中你可以看到，如果在执行动画过程中，其中有个 JavaScript 任务因执行时间过久，占用了动画单帧的时间，这样会给用户制造了卡顿的感觉，这当然是极不好的用户体验。针对这种情况，JavaScript 可以通过回调功能来规避这种问题，也就是让要执行的 JavaScript 任务滞后执行。至于浏览器是如何实现回调功能的，我们在后面的章节中再详细介绍。</p>
<h2 id="实践-浏览器页面是如何运行的" tabindex="-1"><a class="header-anchor" href="#实践-浏览器页面是如何运行的" aria-hidden="true">#</a> 实践：浏览器页面是如何运行的</h2>
<p>有了上面的基础知识之后，我们最后来看看浏览器的页面是如何运行的。</p>
<p>你可以打开开发者工具，点击“Performance”标签，选择左上角的“start porfiling and load page”来记录整个页面加载过程中的事件执行情况，如下图所示：</p>
<p>Performance 页面</p>
<p>从图中可以看出，我们点击展开了 Main 这个项目，其记录了主线程执行过程中的所有任务。图中灰色的就是一个个任务，每个任务下面还有子任务，其中的 Parse HTML 任务，是把 HTML 解析为 DOM 的任务。值得注意的是，在执行 Parse HTML 的时候，如果遇到 JavaScript 脚本，那么会暂停当前的 HTML 解析而去执行 JavaScript 脚本。</p>
<p>至于 Performance 工具，在后面的章节中我们还会详细介绍，在这里你只需要建立一个直观的印象就可以了。</p>
<h2 id="总结" tabindex="-1"><a class="header-anchor" href="#总结" aria-hidden="true">#</a> 总结</h2>
<p>好了，今天就讲到这里，下面我来总结下今天所讲的内容。</p>
<ul>
<li>
<p>如果有一些确定好的任务，可以使用一个单线程来按照顺序处理这些任务，这是第一版线程模型。</p>
</li>
<li>
<p>要在线程执行过程中接收并处理新的任务，就需要引入循环语句和事件系统，这是第二版线程模型。</p>
</li>
<li>
<p>如果要接收其他线程发送过来的任务，就需要引入消息队列，这是第三版线程模型。</p>
</li>
<li>
<p>如果其他进程想要发送任务给页面主线程，那么先通过 IPC 把任务发送给渲染进程的 IO 线程，IO 线程再把任务发送给页面主线程。</p>
</li>
<li>
<p>消息队列机制并不是太灵活，为了适应效率和实时性，引入了微任务。</p>
</li>
</ul>
<p>基于消息队列的设计是目前使用最广的消息架构，无论是安卓还是 Chrome 都采用了类似的任务机制，所以理解了本篇文章的内容后，你再理解其他项目的任务机制也会比较轻松。</p>
<h2 id="思考时间" tabindex="-1"><a class="header-anchor" href="#思考时间" aria-hidden="true">#</a> 思考时间</h2>
<p>今天给你留的思考题是：结合消息队列和事件循环，你认为微任务是什么？引入微任务能带来什么优势呢？</p>
<p>欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。</p>
</template>
